iT邦幫忙

2022 iThome 鐵人賽

DAY 25
0

Abstract

concat() 是一個容易使用且直覺的咩色,但其操作過程會用到 @@isConcatSpreadable 這個 well-known Symbol,覺得非常值得一提,因此這邊會有小篇幅地介紹 IsConcatSpreadable 這個抽象操作。

整篇會分成以下幾個部分:

  • 使用時機
  • 語法
  • 說明
  • 範例
  • 注意事項
  • ECMAScript
  • 結論

concat() 這個 method 的全寫應該是 Array.prototype.concat,有興趣可以看 Day 2 的介紹,這邊會直接使用 concat() 作為替代。

範例的 callback 都會使用箭頭函式做介紹,如果尚不熟悉的話可以參考 MDN 的介紹。

最後會透過分析 ECMAScript 來驗證是否有吻合,如果覺得 ECMAScript 有點艱澀難懂,我們在 Day 4 、Day 5 有介紹其相關術語可以幫助閱讀。


使用時機

當你想要將一個陣列合併其他陣列或其他值時。

concat() 不會變動到所有已存在的陣列,而是回傳一個新的陣列。


語法

concat()

concat(item0, item1, ...,itemN)

參數

concat() 接受 0 至多個 item 參數。

itemN

每個 item 可以是一個陣列、基本型別等值。

如果沒有提供則 conact() 會直接回傳一個新陣列,其內容為原陣列的淺拷貝 (shallow copy)。

Return Value

回傳一個合併後的新陣列,或者對原陣列淺拷貝的新陣列。

Mutability

不會改動到原陣列。


說明

concat() 可以將陣列合併其他陣列或其他型別的值,如果被合併的值 (需為物件型別) 是可以展開的 (spreadable),那 concat() 會先將其展開再合併。

concat() 回傳的是一個新陣列,它會在過程使用淺拷貝 (shallow copy),因此所有參與合併的原陣列都不會被改動到。

concat() 會保留所有參與合併過程的陣列之 empty slot,可以參考下方的 ECMAScript 說明。


範例

Example 1 - 基礎用法

const names = ['Spencer', 'Cate']

console.log(names.concat('Emma', 'Alejo'))
// ['Spencer', 'Cate', 'Emma', 'Alejo']

console.log(names.concat(5, 6, 7))
// ['Spencer', 'Cate', 5, 6, 7]

console.log(names.concat(null, undefined))
// ['Spencer', 'Cate', null, undefined]

console.log(names)
// ['Spencer', 'Cate']

concat() 會在展開原陣列後將提供的參數串聯起來,並且不會改動到原陣列。

Example 2 - 串聯其他物件型別的值

const names = ['Spencer', 'Cate']
const otherNames = ['Emma', 'Alejo']
const people = {0: 'Pedro', 1: 'Russ', length: 2 }

console.log(names.concat(otherNames))
// ['Spencer', 'Cate', 'Emma', 'Alejo']

console.log(names.concat(people, otherNames))
// ['Spencer', 'Cate', {0: 'Pedro', 1: 'Russ'}, 'Emma', 'Alejo']

people[Symbol.isConcatSpreadable] = true
console.log(names.concat(people, otherNames))
// ['Spencer', 'Cate', 'Pedro', 'Russ', 'Emma', 'Alejo']

console.log(names)
// ['Spencer', 'Cate']

concat() 會將陣列展開後串聯起來,而 people 這個物件沒有被展開,但如果我們將其屬性 [Symbol.isConcatSpreadable] 設為 true,則就會被 concat() 展開。

Example 3 - 利用 ECMAScript 演算法做一個簡單的 concat()

const array = [0, 1, 2]
const combined = concat(array, [3, 4], 5, null)
console.log(combined)
// [0, 1, 2, 3, 4, 5, null]

function concat(array, ...rest) {
	const items = [...rest]
	items.unshift([...array])

	return items.reduce((combined, item) => {
		if (Array.isArray(item)) {
			item.forEach(element => {
				combined.push(element)
			})
		} else {
			combined.push(item)
		}
		return combined
	}, [])
}

這邊利用了 ECMAScript concat() 的演算法概念做了一個簡單客製的 concat() 函式,但請注意這是一個非常不嚴謹的函式,因為我們略過了很多檢查跟執行步驟,但這邊證明了我們可以用同樣的演算法達到相同的目的。


注意事項

concat() 被刻意做成通用的,這表示類陣列跟類似陣列的物件都可以使用這個咩色。

要注意 concat() 會將 this 附加到 arguments 的最前面,也就是說呼叫的 concat() 的陣列也會被當作參數的一部分來處理。

雖然陣列初始不會有 [@@isConcatSpreadable] 這個 symbol method,但它也會被 concat() 展開,原因可以參考下方 ECMAScript 的說明。


ECMAScript

25.1

concat() 的演算法並沒有要求呼叫它的一定要是一個陣列,可以從步驟 1 跟 Note 2 得知,為了方便解釋,這邊一律使用陣列來說明;我們來驗證一下它是否被做成了一個通用的咩色:

const fakeArray = {
	0: 0,
	1: 1,
	2: 2,
	length: 3,
	[Symbol.isConcatSpreadable]: true
	}

const combination = [].concat.call(fakeArray, 3, 4)
console.log(combination)
// [0, 1, 2, 3, 4]

演算法的第 2 個步驟挺有趣的,雖然 ArraySpeciesCreate() 看起來就只是單純創造一個陣列,但如果點進去看其演算法,會發現它其實是利用 this 的 constructor 來創造一個陣列;也就是說,這個抽象操作實際上是利用原陣列的 constructor 來創造一個長度為 0 的新陣列。

步驟 4 很值得注意,這裡直接把 this 加進 items 的前方,也就是將原陣列從前方加入 arguments 之中,使其變成待會遍歷的一部分,驗證了上面注意事項的說明。

步驟 5 便開始使用 E 來遍歷所有的 items (包括 this 這個原陣列),它會展開可以展開的 E,並將所有 E 串聯起來。

步驟 5-a 的 IsConcatSpreadable(E) 這個抽象操作可以說是整個演算法中最重要的地方,這邊每次都會利用其來查看 E 是否可以被展開,我們來細看一下它做了什麼:

  • 如果 E 不是一個物件就回傳 false,表示無法將其展開
  • 如果 E 是一個物件就會去查看它的 @@isConactSpreadable 這個 well-known Symbol 是否存在
  • 如果 @@isConcatSpreadable 存在,則會確認其是否可以利用 concat() 展開
  • 如果 @@isConcatSpreadable 不存在,就會判斷 E 是否為一個陣列,如果是陣列則回傳 true,代表這個 E 還是一個可以展開的物件

如果你對 Symbol 不熟悉,可能會覺得上述的操作很迷惑,可以先去看看 MDN 對 will-known Symbol 的描述,我們也會在 Day 30 做介紹;這邊先來驗證一下我們是否可以藉由控制 @@isConcatSpreadable 來決定一個陣列是否要被 concat() 展開:

const array1 = [0, 1, 2]

const combined1 = array1.concat(3, 4, 5)
console.log(combined1)
// [0, 1, 2, 3, 4, 5]

const array2 = [0, 1, 2]
array2[Symbol.isConcatSpreadable] = false

const combined2 = array2.concat(3, 4, 5)
console.log(combined2)
// [Array(3), 3, 4, 5]

在步驟 5 還可以看到,當 E 為一個不可展開的物件時,它會被當作一個單一的項目添加進新陣列,而不會展開它。

步驟 6 乍看可能會覺得有點多餘,但實際上它是為了確保新陣列的長度是正確的,因為 concat() 會把其他陣列的 empty slot 一起串聯起來,要是這個 empty slot 剛好在新陣列的結尾就會造成長度跟原本預期的不一樣,我們來看看以下例子:

const array1 = [0, 1]
const array2 = [2, 3, ,]

const combined = array1.concat(array2)
console.log(combined)
// [0, 1, 2, 3, empty]

在上述的範例中,步驟 6 確保了 combined 結尾的 empty slot 還能存在,且長度為預期的長度。

我們可以看到演算法在陣列的每一次取值前,都會使用 HasProperty() 來檢查元素是否存在,如果元素不存在則不會執行取值,這樣看起來 empty slot 應該會被其忽略,但從結果卻可以看到新陣列仍然保留了這些 empty slot!這是因為陣列物件的一個特性 - length,它為了讓陣列保持該有的長度,讓原本被忽略的元素又重新被 empty slot 填充,所以 concat() 回傳的結果仍然會保有這些 empty slot,關於陣列的更多特性我們會在 Day 30 來做討論。

在演算法還可以看到新陣列最後的長度如果大於 2**53 - 1,便會回傳一個 TypeError,這是因為 JavaScript 最大的合法整數就是 2**52 - 1,可以參考 MDN 對 safe integers 的說明。

出現 ? 的地方代表有可能會丟出錯誤,所以整個演算法有 9 處有機會丟出錯誤,例如步驟 6 的 Set(),當設值失敗便會丟出一個 TyperError 的錯誤;或者步驟 1 的 ToObject() 會在 this 是一個 UndefinedNull 時丟出一個 TypeError 的錯誤,我們來驗證一下:

25.2

如果出現 ! ,則代表這個抽象操作 (abstract operation) 絕對不會丟出錯誤,例如步驟 5-b-iv-1 的 ToString() 它會在參數是一個 Symbol 時丟出一個 TypeError,但我們確定丟進去的是一個 Number (F(k)),因此不會有丟出錯誤的可能。

從 ECMAScript 的演算法來看,尚未找到與 JavaScript 實作的不同之處。


結論

concat() 不難使用,但它裡面藏了一些關於 Symbol 的重要概念,知道這些將會更瞭解 JavaScript 得一些內部操作,我們在 Day 30 會再做介紹。

最後,希望大家可以開心地使用各種咩色,體驗它帶給你的便利,祝大家歸剛沒煩惱。


參考資源


上一篇
Day 24 咩色用得好 - Array.prototype.reverse
下一篇
Day 26 咩色用得好 - Array.prototype.splice (part - 1)
系列文
咩色用得好,歸剛沒煩惱 - 從 ECMAScript 偷窺 JavaScript Array method30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言